route.js 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. import User, { USER_ROLES } from "@/models/user";
  2. import { getDb } from "@/lib/db";
  3. import { getSession } from "@/lib/auth/session";
  4. import { requireUserManagement } from "@/lib/auth/permissions";
  5. import {
  6. withErrorHandling,
  7. json,
  8. badRequest,
  9. unauthorized,
  10. notFound,
  11. } from "@/lib/api/errors";
  12. export const dynamic = "force-dynamic";
  13. const BRANCH_RE = /^NL\d+$/;
  14. const OBJECT_ID_RE = /^[a-f0-9]{24}$/i;
  15. const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{2,31}$/; // 3..32, conservative
  16. const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  17. const ALLOWED_ROLES = new Set(Object.values(USER_ROLES));
  18. const ALLOWED_UPDATE_FIELDS = Object.freeze([
  19. "username",
  20. "email",
  21. "role",
  22. "branchId",
  23. "mustChangePassword",
  24. ]);
  25. function isPlainObject(value) {
  26. return Boolean(value && typeof value === "object" && !Array.isArray(value));
  27. }
  28. function isNonEmptyString(value) {
  29. return typeof value === "string" && value.trim().length > 0;
  30. }
  31. function normalizeUsername(value) {
  32. return String(value || "")
  33. .trim()
  34. .toLowerCase();
  35. }
  36. function normalizeEmail(value) {
  37. return String(value || "")
  38. .trim()
  39. .toLowerCase();
  40. }
  41. function normalizeBranchId(value) {
  42. return String(value || "")
  43. .trim()
  44. .toUpperCase();
  45. }
  46. function toIsoOrNull(value) {
  47. if (!value) return null;
  48. try {
  49. return new Date(value).toISOString();
  50. } catch {
  51. return null;
  52. }
  53. }
  54. function toSafeUser(doc) {
  55. return {
  56. id: String(doc?._id),
  57. username: typeof doc?.username === "string" ? doc.username : "",
  58. email: typeof doc?.email === "string" ? doc.email : "",
  59. role: typeof doc?.role === "string" ? doc.role : "",
  60. branchId: doc?.branchId ?? null,
  61. mustChangePassword: Boolean(doc?.mustChangePassword),
  62. createdAt: toIsoOrNull(doc?.createdAt),
  63. updatedAt: toIsoOrNull(doc?.updatedAt),
  64. };
  65. }
  66. function pickDuplicateField(err) {
  67. if (!err || typeof err !== "object") return null;
  68. const keyValue =
  69. err.keyValue && typeof err.keyValue === "object" ? err.keyValue : null;
  70. if (keyValue) {
  71. const keys = Object.keys(keyValue);
  72. if (keys.length > 0) return keys[0];
  73. }
  74. const keyPattern =
  75. err.keyPattern && typeof err.keyPattern === "object"
  76. ? err.keyPattern
  77. : null;
  78. if (keyPattern) {
  79. const keys = Object.keys(keyPattern);
  80. if (keys.length > 0) return keys[0];
  81. }
  82. return null;
  83. }
  84. export const PATCH = withErrorHandling(
  85. async function PATCH(request, ctx) {
  86. const session = await getSession();
  87. if (!session) {
  88. throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
  89. }
  90. requireUserManagement(session);
  91. const { userId } = await ctx.params;
  92. if (!userId) {
  93. throw badRequest(
  94. "VALIDATION_MISSING_PARAM",
  95. "Missing required route parameter(s)",
  96. { params: ["userId"] },
  97. );
  98. }
  99. if (!OBJECT_ID_RE.test(String(userId))) {
  100. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid userId", {
  101. field: "userId",
  102. value: userId,
  103. });
  104. }
  105. let body;
  106. try {
  107. body = await request.json();
  108. } catch {
  109. throw badRequest("VALIDATION_INVALID_JSON", "Invalid request body");
  110. }
  111. if (!isPlainObject(body)) {
  112. throw badRequest("VALIDATION_INVALID_BODY", "Invalid request body");
  113. }
  114. const hasUpdateField = Object.keys(body).some((k) =>
  115. ALLOWED_UPDATE_FIELDS.includes(k),
  116. );
  117. if (!hasUpdateField) {
  118. throw badRequest("VALIDATION_MISSING_FIELD", "Missing fields to update", {
  119. fields: [...ALLOWED_UPDATE_FIELDS],
  120. });
  121. }
  122. await getDb();
  123. const user = await User.findById(String(userId)).exec();
  124. if (!user) {
  125. throw notFound("USER_NOT_FOUND", "Not found", { userId: String(userId) });
  126. }
  127. const patch = {};
  128. // username (optional)
  129. if (Object.prototype.hasOwnProperty.call(body, "username")) {
  130. if (!isNonEmptyString(body.username)) {
  131. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid username", {
  132. field: "username",
  133. value: body.username,
  134. });
  135. }
  136. const username = normalizeUsername(body.username);
  137. if (!USERNAME_RE.test(username)) {
  138. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid username", {
  139. field: "username",
  140. value: username,
  141. pattern: String(USERNAME_RE),
  142. });
  143. }
  144. const existing = await User.findOne({
  145. username,
  146. _id: { $ne: String(userId) },
  147. })
  148. .select("_id")
  149. .exec();
  150. if (existing) {
  151. throw badRequest(
  152. "VALIDATION_INVALID_FIELD",
  153. "Username already exists",
  154. {
  155. field: "username",
  156. value: username,
  157. },
  158. );
  159. }
  160. patch.username = username;
  161. }
  162. // email (optional)
  163. if (Object.prototype.hasOwnProperty.call(body, "email")) {
  164. if (!isNonEmptyString(body.email)) {
  165. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid email", {
  166. field: "email",
  167. value: body.email,
  168. });
  169. }
  170. const email = normalizeEmail(body.email);
  171. if (!EMAIL_RE.test(email)) {
  172. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid email", {
  173. field: "email",
  174. value: email,
  175. });
  176. }
  177. const existing = await User.findOne({
  178. email,
  179. _id: { $ne: String(userId) },
  180. })
  181. .select("_id")
  182. .exec();
  183. if (existing) {
  184. throw badRequest("VALIDATION_INVALID_FIELD", "Email already exists", {
  185. field: "email",
  186. value: email,
  187. });
  188. }
  189. patch.email = email;
  190. }
  191. // role (optional)
  192. if (Object.prototype.hasOwnProperty.call(body, "role")) {
  193. if (!isNonEmptyString(body.role)) {
  194. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid role", {
  195. field: "role",
  196. value: body.role,
  197. allowed: Array.from(ALLOWED_ROLES),
  198. });
  199. }
  200. const role = String(body.role).trim();
  201. if (!ALLOWED_ROLES.has(role)) {
  202. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid role", {
  203. field: "role",
  204. value: role,
  205. allowed: Array.from(ALLOWED_ROLES),
  206. });
  207. }
  208. patch.role = role;
  209. }
  210. // branchId (optional, can be null)
  211. if (Object.prototype.hasOwnProperty.call(body, "branchId")) {
  212. if (body.branchId === null) {
  213. patch.branchId = null;
  214. } else if (isNonEmptyString(body.branchId)) {
  215. patch.branchId = normalizeBranchId(body.branchId);
  216. } else {
  217. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid branchId", {
  218. field: "branchId",
  219. value: body.branchId,
  220. pattern: "^NL\\d+$",
  221. });
  222. }
  223. }
  224. // mustChangePassword (optional)
  225. if (Object.prototype.hasOwnProperty.call(body, "mustChangePassword")) {
  226. if (typeof body.mustChangePassword !== "boolean") {
  227. throw badRequest(
  228. "VALIDATION_INVALID_FIELD",
  229. "Invalid mustChangePassword",
  230. {
  231. field: "mustChangePassword",
  232. value: body.mustChangePassword,
  233. },
  234. );
  235. }
  236. patch.mustChangePassword = body.mustChangePassword;
  237. }
  238. // --- Enforce role <-> branchId consistency --------------------------------
  239. const nextRole = patch.role ?? user.role;
  240. const nextBranchId = Object.prototype.hasOwnProperty.call(patch, "branchId")
  241. ? patch.branchId
  242. : (user.branchId ?? null);
  243. if (nextRole === USER_ROLES.BRANCH) {
  244. if (!isNonEmptyString(nextBranchId)) {
  245. throw badRequest(
  246. "VALIDATION_MISSING_FIELD",
  247. "Missing required fields",
  248. {
  249. fields: ["branchId"],
  250. },
  251. );
  252. }
  253. const normalized = normalizeBranchId(nextBranchId);
  254. if (!BRANCH_RE.test(normalized)) {
  255. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid branchId", {
  256. field: "branchId",
  257. value: normalized,
  258. pattern: "^NL\\d+$",
  259. });
  260. }
  261. patch.branchId = normalized;
  262. } else {
  263. // For non-branch users, always clear branchId
  264. patch.branchId = null;
  265. }
  266. // --- Apply patch ----------------------------------------------------------
  267. if (Object.prototype.hasOwnProperty.call(patch, "username"))
  268. user.username = patch.username;
  269. if (Object.prototype.hasOwnProperty.call(patch, "email"))
  270. user.email = patch.email;
  271. if (Object.prototype.hasOwnProperty.call(patch, "role"))
  272. user.role = patch.role;
  273. if (Object.prototype.hasOwnProperty.call(patch, "branchId"))
  274. user.branchId = patch.branchId;
  275. if (Object.prototype.hasOwnProperty.call(patch, "mustChangePassword"))
  276. user.mustChangePassword = patch.mustChangePassword;
  277. try {
  278. await user.save();
  279. } catch (err) {
  280. if (err && err.code === 11000) {
  281. const field = pickDuplicateField(err) || "unknown";
  282. throw badRequest("VALIDATION_INVALID_FIELD", "Duplicate key", {
  283. field,
  284. });
  285. }
  286. throw err;
  287. }
  288. return json({ ok: true, user: toSafeUser(user) }, 200);
  289. },
  290. { logPrefix: "[api/admin/users/[userId]]" },
  291. );
  292. export const DELETE = withErrorHandling(
  293. async function DELETE(request, ctx) {
  294. const session = await getSession();
  295. if (!session) {
  296. throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
  297. }
  298. requireUserManagement(session);
  299. const { userId } = await ctx.params;
  300. if (!userId) {
  301. throw badRequest(
  302. "VALIDATION_MISSING_PARAM",
  303. "Missing required route parameter(s)",
  304. { params: ["userId"] },
  305. );
  306. }
  307. if (!OBJECT_ID_RE.test(String(userId))) {
  308. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid userId", {
  309. field: "userId",
  310. value: userId,
  311. });
  312. }
  313. // Safety: prevent deleting your own currently active account.
  314. if (String(session.userId) === String(userId)) {
  315. throw badRequest(
  316. "VALIDATION_INVALID_FIELD",
  317. "Cannot delete current user",
  318. {
  319. field: "userId",
  320. reason: "SELF_DELETE_FORBIDDEN",
  321. },
  322. );
  323. }
  324. await getDb();
  325. const deleted = await User.findByIdAndDelete(String(userId))
  326. .select(
  327. "_id username email role branchId mustChangePassword createdAt updatedAt",
  328. )
  329. .exec();
  330. if (!deleted) {
  331. throw notFound("USER_NOT_FOUND", "Not found", { userId: String(userId) });
  332. }
  333. return json({ ok: true, user: toSafeUser(deleted) }, 200);
  334. },
  335. { logPrefix: "[api/admin/users/[userId]]" },
  336. );